/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';

/**
 * In JS, unicode code points above 0xFFFF occupy two elements of a string.
 * For example '𐀏'.length is 2. An occurence of such a code point is called a
 * surrogate pair.
 *
 * This regex segments a string along tabs ('\t') and surrogate pairs, since
 * these are two cases where '1 char' does not automatically imply '1 column'.
 *
 * TODO: For human languages whose orthographies use combining marks, this
 * approach won't correctly identify the grapheme boundaries. In those cases,
 * a grapheme consists of multiple code points that should count as only one
 * character against the column limit. Getting that correct (if it's desired)
 * is probably beyond the limits of a regex, but there are nonstandard APIs to
 * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
 *
 * Further reading:
 *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
 *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
 *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
 */
const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;

export function GrDiffBuilder(diff, prefs, outputEl, layers) {
  this._diff = diff;
  this._numLinesLeft = this._diff.content ? this._diff.content.reduce(
      (sum, chunk) => {
        const left = chunk.a || chunk.ab;
        return sum + (left ? left.length : 0);
      }, 0) : 0;
  this._prefs = prefs;
  this._outputEl = outputEl;
  this.groups = [];
  this._blameInfo = null;

  this.layers = layers || [];

  if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
    throw Error('Invalid tab size from preferences.');
  }

  if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
    throw Error('Invalid line length from preferences.');
  }

  this._layerUpdateListener = this._handleLayerUpdate.bind(this);
  for (const layer of this.layers) {
    if (layer.addListener) {
      layer.addListener(this._layerUpdateListener);
    }
  }
}

GrDiffBuilder.prototype.clear = function() {
  for (const layer of this.layers) {
    if (layer.removeListener) {
      layer.removeListener(this._layerUpdateListener);
    }
  }
};

GrDiffBuilder.GroupType = {
  ADDED: 'b',
  BOTH: 'ab',
  REMOVED: 'a',
};

GrDiffBuilder.Highlights = {
  ADDED: 'edit_b',
  REMOVED: 'edit_a',
};

GrDiffBuilder.Side = {
  LEFT: 'left',
  RIGHT: 'right',
};

GrDiffBuilder.ContextButtonType = {
  ABOVE: 'above',
  BELOW: 'below',
  ALL: 'all',
};

const PARTIAL_CONTEXT_AMOUNT = 10;

/**
 * Abstract method
 *
 * @param {string} outputEl
 * @param {number} fontSize
 */
GrDiffBuilder.prototype.addColumns = function() {
  throw Error('Subclasses must implement addColumns');
};

/**
 * Abstract method
 *
 * @param {Object} group
 */
GrDiffBuilder.prototype.buildSectionElement = function() {
  throw Error('Subclasses must implement buildSectionElement');
};

GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
  const element = this.buildSectionElement(group);
  this._outputEl.insertBefore(element, opt_beforeSection);
  group.element = element;
};

GrDiffBuilder.prototype.getGroupsByLineRange = function(
    startLine, endLine, opt_side) {
  const groups = [];
  for (let i = 0; i < this.groups.length; i++) {
    const group = this.groups[i];
    if (group.lines.length === 0) {
      continue;
    }
    let groupStartLine = 0;
    let groupEndLine = 0;
    if (opt_side) {
      groupStartLine = group.lineRange[opt_side].start;
      groupEndLine = group.lineRange[opt_side].end;
    }

    if (groupStartLine === 0) { // Line was removed or added.
      groupStartLine = groupEndLine;
    }
    if (groupEndLine === 0) { // Line was removed or added.
      groupEndLine = groupStartLine;
    }
    if (startLine <= groupEndLine && endLine >= groupStartLine) {
      groups.push(group);
    }
  }
  return groups;
};

GrDiffBuilder.prototype.getContentTdByLine = function(
    lineNumber, opt_side, opt_root) {
  const root = dom(opt_root || this._outputEl);
  const sideSelector = opt_side ? ('.' + opt_side) : '';
  return root.querySelector('td.lineNum[data-value="' + lineNumber +
    '"]' + sideSelector + ' ~ td.content');
};

GrDiffBuilder.prototype.getContentByLine = function(
    lineNumber, opt_side, opt_root) {
  return this.getContentTdByLine(lineNumber, opt_side, opt_root)
      .querySelector('.contentText');
};

/**
 * Find line elements or line objects by a range of line numbers and a side.
 *
 * @param {number} start The first line number
 * @param {number} end The last line number
 * @param {string} opt_side The side of the range. Either 'left' or 'right'.
 * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
 *     null if not desired.
 * @param  {!Array<HTMLElement>} out_elements The output list of line elements.
 *     Use null if not desired.
 */
GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
    out_lines, out_elements) {
  const groups = this.getGroupsByLineRange(start, end, opt_side);
  for (const group of groups) {
    let content = null;
    for (const line of group.lines) {
      if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
          (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
        continue;
      }
      const lineNumber = opt_side === 'left' ?
        line.beforeNumber : line.afterNumber;
      if (lineNumber < start || lineNumber > end) { continue; }

      if (out_lines) { out_lines.push(line); }
      if (out_elements) {
        if (content) {
          content = this._getNextContentOnSide(content, opt_side);
        } else {
          content = this.getContentByLine(lineNumber, opt_side,
              group.element);
        }
        if (content) { out_elements.push(content); }
      }
    }
  }
};

/**
 * Re-renders the DIV.contentText elements for the given side and range of
 * diff content.
 */
GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
  const lines = [];
  const elements = [];
  let line;
  let el;
  this.findLinesByRange(start, end, side, lines, elements);
  for (let i = 0; i < lines.length; i++) {
    line = lines[i];
    el = elements[i];
    if (!el) {
      // Cannot re-render an element if it does not exist. This can happen
      // if lines are collapsed and not visible on the page yet.
      continue;
    }
    const lineNumberEl = this._getLineNumberEl(el, side);
    el.parentElement.replaceChild(
        this._createTextEl(lineNumberEl, line, side).firstChild,
        el);
  }
};

GrDiffBuilder.prototype.getSectionsByLineRange = function(
    startLine, endLine, opt_side) {
  return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
      group => group.element);
};

GrDiffBuilder.prototype._createContextControl = function(section, line) {
  if (!line.contextGroups) return null;

  const leftStart = line.contextGroups[0].lineRange.left.start;
  const leftEnd =
      line.contextGroups[line.contextGroups.length - 1].lineRange.left.end;

  const numLines = leftEnd - leftStart + 1;

  if (numLines === 0) return null;

  const td = this._createElement('td');
  const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;

  if (showPartialLinks && leftStart > 1) {
    td.appendChild(this._createContextButton(
        GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
  }

  td.appendChild(this._createContextButton(
      GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));

  if (showPartialLinks && leftEnd < this._numLinesLeft) {
    td.appendChild(this._createContextButton(
        GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
  }

  return td;
};

GrDiffBuilder.prototype._createContextButton = function(type, section, line,
    numLines) {
  const context = PARTIAL_CONTEXT_AMOUNT;

  const button = this._createElement('gr-button', 'showContext');
  button.setAttribute('link', true);
  button.setAttribute('no-uppercase', true);

  let text;
  let groups = []; // The groups that replace this one if tapped.
  if (type === GrDiffBuilder.ContextButtonType.ALL) {
    const icon = this._createElement('iron-icon', 'showContext');
    icon.setAttribute('icon', 'gr-icons:unfold-more');
    dom(button).appendChild(icon);

    text = 'Show ' + numLines + ' common line';
    if (numLines > 1) { text += 's'; }
    groups.push(...line.contextGroups);
  } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
    text = '+' + context + ' above';
    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
        context, numLines);
  } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
    text = '+' + context + ' below';
    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
        0, numLines - context);
  }
  const textSpan = this._createElement('span', 'showContext');
  dom(textSpan).textContent = text;
  dom(button).appendChild(textSpan);

  button.addEventListener('tap', e => {
    e.detail = {
      groups,
      section,
      numLines,
    };
    // Let it bubble up the DOM tree.
  });

  return button;
};

GrDiffBuilder.prototype._createLineEl = function(
    line, number, type, side) {
  const td = this._createElement('td');
  if (line.type === GrDiffLine.Type.BLANK) {
    return td;
  }
  if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
    td.classList.add('contextLineNum');
    return td;
  }

  if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
    // Both td and button need a number of classes/attributes for various
    // selectors to work.
    this._decorateLineEl(td, number, side);
    td.classList.add('lineNum');

    if (this._prefs.show_file_comment_button === false && number === 'FILE') {
      return td;
    }

    const button = this._createElement('button');
    td.appendChild(button);
    button.tabIndex = -1;
    this._decorateLineEl(button, number, side);

    button.classList.add('lineNumButton');

    button.textContent = number === 'FILE' ? 'File' : number;

    // Add aria-labels for valid line numbers.
    // For unified diff, this method will be called with number set to 0 for
    // the empty line number column for added/removed lines. This should not
    // be announced to the screenreader.
    if (number > 0) {
      if (line.type === GrDiffLine.Type.REMOVE) {
        button.setAttribute('aria-label', `${number} removed`);
      } else if (line.type === GrDiffLine.Type.ADD) {
        button.setAttribute('aria-label', `${number} added`);
      }
    }
  }

  return td;
};

GrDiffBuilder.prototype._decorateLineEl = function(el, number, side) {
  el.classList.add(side);
  el.dataset.value = number;
};

GrDiffBuilder.prototype._createTextEl = function(
    lineNumberEl, line, opt_side) {
  const td = this._createElement('td');
  if (line.type !== GrDiffLine.Type.BLANK) {
    td.classList.add('content');
  }

  // If intraline info is not available, the entire line will be
  // considered as changed and marked as dark red / green color
  if (!line.hasIntralineInfo) {
    td.classList.add('no-intraline-info');
  }
  td.classList.add(line.type);

  if (line.beforeNumber !== 'FILE') {
    const lineLimit =
        !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
    const contentText =
        this._formatText(line.text, this._prefs.tab_size, lineLimit);

    if (opt_side) {
      contentText.setAttribute('data-side', opt_side);
    }

    for (const layer of this.layers) {
      if (typeof layer.annotate == 'function') {
        layer.annotate(contentText, lineNumberEl, line);
      }
    }

    td.appendChild(contentText);
  } else {
    td.classList.add('file');
  }

  return td;
};

/**
 * Returns a 'div' element containing the supplied |text| as its innerText,
 * with '\t' characters expanded to a width determined by |tabSize|, and the
 * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
 * desired.
 *
 * @param {string} text The text to be formatted.
 * @param {number} tabSize The width of each tab stop.
 * @param {number} lineLimit The column after which to wrap lines.
 * @return {HTMLElement}
 */
GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
  const contentText = this._createElement('div', 'contentText');

  let columnPos = 0;
  let textOffset = 0;
  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
    if (segment) {
      // |segment| contains only normal characters. If |segment| doesn't fit
      // entirely on the current line, append chunks of |segment| followed by
      // line breaks.
      let rowStart = 0;
      let rowEnd = lineLimit - columnPos;
      while (rowEnd < segment.length) {
        contentText.appendChild(
            document.createTextNode(segment.substring(rowStart, rowEnd)));
        contentText.appendChild(this._createElement('span', 'br'));
        columnPos = 0;
        rowStart = rowEnd;
        rowEnd += lineLimit;
      }
      // Append the last part of |segment|, which fits on the current line.
      contentText.appendChild(
          document.createTextNode(segment.substring(rowStart)));
      columnPos += (segment.length - rowStart);
      textOffset += segment.length;
    }
    if (textOffset < text.length) {
      // Handle the special character at |textOffset|.
      if (text.startsWith('\t', textOffset)) {
        // Append a single '\t' character.
        let effectiveTabSize = tabSize - (columnPos % tabSize);
        if (columnPos + effectiveTabSize > lineLimit) {
          contentText.appendChild(this._createElement('span', 'br'));
          columnPos = 0;
          effectiveTabSize = tabSize;
        }
        contentText.appendChild(this._getTabWrapper(effectiveTabSize));
        columnPos += effectiveTabSize;
        textOffset++;
      } else {
        // Append a single surrogate pair.
        if (columnPos >= lineLimit) {
          contentText.appendChild(this._createElement('span', 'br'));
          columnPos = 0;
        }
        contentText.appendChild(document.createTextNode(
            text.substring(textOffset, textOffset + 2)));
        textOffset += 2;
        columnPos += 1;
      }
    }
  }
  return contentText;
};

/**
 * Returns a <span> element holding a '\t' character, that will visually
 * occupy |tabSize| many columns.
 *
 * @param {number} tabSize The effective size of this tab stop.
 * @return {HTMLElement}
 */
GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
  // Force this to be a number to prevent arbitrary injection.
  const result = this._createElement('span', 'tab');
  result.style['tab-size'] = tabSize;
  result.style['-moz-tab-size'] = tabSize;
  result.innerText = '\t';
  return result;
};

GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
  const el = document.createElement(tagName);
  // When Shady DOM is being used, these classes are added to account for
  // Polymer's polyfill behavior. In order to guarantee sufficient
  // specificity within the CSS rules, these are added to every element.
  // Since the Polymer DOM utility functions (which would do this
  // automatically) are not being used for performance reasons, this is
  // done manually.
  el.classList.add('style-scope', 'gr-diff');
  if (classStr) {
    for (const className of classStr.split(' ')) {
      el.classList.add(className);
    }
  }
  return el;
};

GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
  this._renderContentByRange(start, end, side);
};

/**
 * Finds the next DIV.contentText element following the given element, and on
 * the same side. Will only search within a group.
 *
 * @param {HTMLElement} content
 * @param {string} side Either 'left' or 'right'
 * @return {HTMLElement}
 */
GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
  throw Error('Subclasses must implement _getNextContentOnSide');
};

/**
 * Determines whether the given group is either totally an addition or totally
 * a removal.
 *
 * @param {!Object} group (GrDiffGroup)
 * @return {boolean}
 */
GrDiffBuilder.prototype._isTotal = function(group) {
  return group.type === GrDiffGroup.Type.DELTA &&
      (!group.adds.length || !group.removes.length) &&
      !(!group.adds.length && !group.removes.length);
};

/**
 * Set the blame information for the diff. For any already-rendered line,
 * re-render its blame cell content.
 *
 * @param {Object} blame
 */
GrDiffBuilder.prototype.setBlame = function(blame) {
  this._blameInfo = blame;

  // TODO(wyatta): make this loop asynchronous.
  for (const commit of blame) {
    for (const range of commit.ranges) {
      for (let i = range.start; i <= range.end; i++) {
        // TODO(wyatta): this query is expensive, but, when traversing a
        // range, the lines are consecutive, and given the previous blame
        // cell, the next one can be reached cheaply.
        const el = this._getBlameByLineNum(i);
        if (!el) { continue; }
        // Remove the element's children (if any).
        while (el.hasChildNodes()) {
          el.removeChild(el.lastChild);
        }
        const blame = this._getBlameForBaseLine(i, commit);
        el.appendChild(blame);
      }
    }
  }
};

/**
 * Find the blame cell for a given line number.
 *
 * @param {number} lineNum
 * @return {HTMLTableDataCellElement}
 */
GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
  const root = dom(this._outputEl);
  return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
};

/**
 * Given a base line number, return the commit containing that line in the
 * current set of blame information. If no blame information has been
 * provided, null is returned.
 *
 * @param {number} lineNum
 * @return {Object} The commit information.
 */
GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
  if (!this._blameInfo) { return null; }

  for (const blameCommit of this._blameInfo) {
    for (const range of blameCommit.ranges) {
      if (range.start <= lineNum && range.end >= lineNum) {
        return blameCommit;
      }
    }
  }
  return null;
};

/**
 * Given the number of a base line, get the content for the blame cell of that
 * line. If there is no blame information for that line, returns null.
 *
 * @param {number} lineNum
 * @param {Object=} opt_commit Optionally provide the commit object, so that
 *     it does not need to be searched.
 * @return {HTMLSpanElement}
 */
GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
  const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
  if (!commit) { return null; }

  const isStartOfRange = commit.ranges.some(r => r.start === lineNum);

  const date = (new Date(commit.time * 1000)).toLocaleDateString();
  const blameNode = this._createElement('span',
      isStartOfRange ? 'startOfRange' : '');

  const shaNode = this._createElement('a', 'blameDate');
  shaNode.innerText = `${date}`;
  shaNode.setAttribute('href', `/q/${commit.id}`);
  blameNode.appendChild(shaNode);

  const shortName = commit.author.split(' ')[0];
  const authorNode = this._createElement('span', 'blameAuthor');
  authorNode.innerText = ` ${shortName}`;
  blameNode.appendChild(authorNode);

  const hoverCardFragment = this._createElement('span', 'blameHoverCard');
  hoverCardFragment.innerText =
    `Commit ${commit.id}
Author: ${commit.author}
Date: ${date}

${commit.commit_msg}`;
  const hovercard = this._createElement('gr-hovercard');
  hovercard.appendChild(hoverCardFragment);
  blameNode.appendChild(hovercard);

  return blameNode;
};

/**
 * Create a blame cell for the given base line. Blame information will be
 * included in the cell if available.
 *
 * @param {GrDiffLine} line
 * @return {HTMLTableDataCellElement}
 */
GrDiffBuilder.prototype._createBlameCell = function(line) {
  const blameTd = this._createElement('td', 'blame');
  blameTd.setAttribute('data-line-number', line.beforeNumber);
  if (line.beforeNumber) {
    const content = this._getBlameForBaseLine(line.beforeNumber);
    if (content) {
      blameTd.appendChild(content);
    }
  }
  return blameTd;
};

/**
 * Finds the line number element given the content element by walking up the
 * DOM tree to the diff row and then querying for a .lineNum element on the
 * requested side.
 *
 * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
 */
GrDiffBuilder.prototype._getLineNumberEl = function(content, side) {
  let row = content;
  while (row && !row.classList.contains('diff-row')) row = row.parentElement;
  return row ? row.querySelector('.lineNum.' + side) : null;
};
